iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
AI Security

我做了一個Claude skill的掃毒軟體學到的那些事系列 第 27 篇

# 直攻檢索漏洞:語意模糊也能偷看機密?向量資料庫弱點與權限感知檢索(Permission-Aware Retrieval)

  • 分享至 

  • xImage
  •  

先講結論:權限感知檢索是什麼

權限感知檢索(Permission-Aware Retrieval)一句話講完:在 AI 去資料庫「找資料」的那一步,就先確認「問問題的這個人有沒有資格看」,讓他沒權限的資料根本不會被交到 AI 手上。

今天這篇是我自己手打的筆記加AI的整理,希望對正在打造 RAG 知識庫的夥伴有幫助。先把幾個名詞講清楚:

  • RAG(Retrieval-Augmented Generation,檢索增強生成):讓 AI 回答前先去公司自己的資料庫「翻小抄」,再拿翻到的段落來回答。就像開書考。
  • 檢索(Retrieval):「翻小抄」這個動作本身。
  • 權限感知(Permission-Aware):翻小抄的人知道「哪些小抄可以給誰看」。

為什麼需要懂它?因為企業導入 RAG 時最常見的一個錯誤,就是把全公司的文件一腦兒丟進向量資料庫,對所有人開放同一個問答機器人。原本在雲端硬碟裡只有人資看得到的薪資表,現在實習生問一句「誰領的錢最多」就可能被撈出來。OWASP(開放式 Web 應用程式安全計畫,一個制定資安風險清單的非營利組織)在 2025 年版的 LLM 十大風險中,把「向量與嵌入弱點(LLM08)」獨立列為一項,其中第一個風險就是「未授權存取與資料外洩」。權限感知檢索就是針對這個風險的解法。

它的位置是事前預防:審計日誌(Audit Log,系統把「誰在什麼時候看了什麼」記下來的紀錄)只能在事情發生後追溯,權限感知檢索則是讓不該發生的事一開始就不發生。具體做法有三種:前過濾、後過濾,以及實務上最常用的「混合式」。

一、背景脈絡與問題意識

1. 資料的三種主要型態

要懂權限為什麼會不見,得先知道資料在系統裡長什麼樣子、怎麼被找到。

型態 例子 怎麼找資料 權限通常放在哪
結構化資料 Excel、關聯式資料庫(SQL) 下精準條件,例如「部門 = 業務部」 資料庫的帳號、表格、欄位權限
非結構化資料 純文字、Word、PDF 全文關鍵字比對(字要一模一樣) 檔案系統或 NAS 的存取控制清單
向量資料 RAG 知識庫裡的文字切片 算「意思有多接近」(距離越短越相關) 預設沒有,要自己放

幾個名詞:

  • 向量(Vector):一串數字,例如 [0.12, -0.8, 0.33, …]。可以想成地圖上的座標,只是不是二維,而是好幾百到上千維。
  • 嵌入模型(Embedding Model):把一段文字變成向量的語言模型。意思接近的文字,會被放在地圖上靠近的位置。
  • NAS(Network Attached Storage,網路附接儲存):公司內部的共用硬碟。
  • ACL(Access Control List,存取控制清單):貼在每個檔案上的一張名單,寫著「誰可以讀、誰可以改」。

2. 向量資料庫裡的一筆資料,除了向量還有什麼?

很多人以為向量資料庫只存一串數字,其實一筆紀錄通常有三個部分:

  1. 編號(ID):這個切片的身分證字號。
  2. 向量(Vector):拿來算「意思距離」的座標。
  3. 附帶資料(Metadata,有些資料庫叫 Payload):原始文字、來自哪份文件、作者、日期……以及——如果你有放的話——權限標籤。

重點在第 3 點「如果你有放的話」。另外要注意:向量本身也不是「看不懂所以安全」的亂碼。研究已經證實,可以從向量反推回接近原文的內容(稱為嵌入反轉攻擊,Embedding Inversion),OWASP 也把它列為 LLM08 的風險之一。

3. 核心問題:為什麼傳統權限在向量資料庫會失效?

傳統的搜尋系統本來就有權限控管。你在 Google Drive 搜尋「薪資」,沒權限的檔案不會出現。但把文件搬進 RAG 時,有兩個環節會讓權限掉地上:

第一,切碎(Chunking)時權限沒跟著走。 一份 30 頁的 PDF 會被切成幾百個小段落、各自轉成向量。大部分入門教學只教「讀檔 → 切片 → 轉向量 → 存進去」,沒有一步是「把原檔的 ACL 一起帶過來」。結果所有切片被倒進同一個大鍋,不分來源、不分密級。

第二,語意搜尋讓「猜」變得很便宜。 這裡要講精準一點:真正的漏洞是「檢索層沒有權限檢查」,不是「語意搜尋穿透了權限」。就算用關鍵字搜尋,只要沒檢查權限一樣會漏;差別在於,關鍵字搜尋要猜中「薪資表」這三個字,語意搜尋只要問「誰領的錢最多」「主管年終拿多少」這種意思相近的句子就能命中。攻擊者不用知道檔名,甚至不用知道這份文件存在。

更麻煩的是,很多人的對策是在 System Prompt(系統提示詞,寫給 AI 的底層指令)裡加一句「不要透露機密」。但只要機密已經被撈進 AI 的上下文,就只剩「AI 聽不聽話」這道防線,而提示詞注入(Prompt Injection)就是專門讓 AI 不聽話的技術。

4. 現在的常規解法

解法 怎麼做 問題
提示詞防守 System Prompt 寫「不要透露」 資料已經在 AI 手上,靠自律不是靠權限
審計日誌 記錄誰問了什麼、拿到什麼 只能事後追溯,資料已經外洩
依部門分庫 人資一個庫、業務一個庫 跨部門專案、個人層級權限切不動,庫會越分越多
權限感知檢索 在檢索那一步就按使用者身分過濾 實作有取捨,就是本文重點

依部門分庫其實是權限感知檢索的一種粗糙版本,適合部門邊界非常清楚的小公司。

5. 價值主張

權限感知檢索做的事情很單純:把公司原本就有的權限規則,延伸到 AI 翻小抄的那一步。 它帶來三件事:

  • 一個知識庫服務所有人:不用為每個部門複製一份資料。
  • 安全不靠 AI 自律:沒權限的資料根本不會進入 AI 的上下文,Prompt Injection 再厲害也套不出它沒看過的東西。
  • 權限紀錄一致:AI 看得到什麼,跟這個人自己打開 Google Drive 看得到什麼,是同一件事。

二、核心原則與粗略共識

進入前後過濾之前,先立幾個座標。後面每一種做法的優缺點,其實都是在這幾條原則之間做取捨。

原則一:AI 不是權限邊界。 權限要在「檢索層」就決定,不能留到「生成層」讓 AI 自己判斷。理由很簡單:語言模型的行為是機率性的,可以被提示詞操弄;資料庫的過濾條件是確定性的,不會因為你講得很委屈就放行。

原則二:單一真實來源(Single Source of Truth)。 「誰能看什麼」的最終答案,只能有一個地方說了算,通常是公司原本的身分系統(例如 Active Directory,微軟的企業帳號管理系統)或檔案系統的 ACL。向量資料庫裡的權限標籤只是「副本」,副本一定會有過期的時候。

原則三:預設拒絕(Fail Closed)。 權限系統掛了、查不到、逾時——一律當作「沒權限」。寧可讓使用者少看到一筆資料,也不要讓他多看到一筆機密。

原則四:權限綁「群組」,不綁「個人」。 切片上貼的是「人資部」「主管」這類群組名稱,而不是「王小明」「李大華」。這樣有人升遷、換部門、離職時,只要在身分系統裡把他移進移出群組,向量資料庫一個字都不用改。這條原則會在後面大幅緩解「前過濾的權限同步地獄」。

原則五:不讓回應洩漏「存在性」。 沒權限的資料,對使用者來說就應該「不存在」。不要回「您無權存取」,因為這句話本身就告訴對方「這裡有東西」。

原則六:縱深防禦(Defense in Depth)。 不要期待單一一道關卡完美。這也是為什麼實務上最後會走向「混合式」:前過濾擋掉大部分,後過濾再驗一次。

三、具體做法:前過濾、後過濾與混合式

想像你是一間大圖書館的館員,館裡有 10 萬本書,其中 1 萬本是只有主管能看的機密。今天一位實習生來問「公司財務營運相關的資料」,你要找資料給他,而且「絕對不能讓他看到機密帳冊」。你有三種做法。

做法一:前過濾(Pre-filtering)

比喻: 書上架時,你就在每本書封面貼彩色標籤(紅標:僅限主管;綠標:全員公開)。實習生來問問題時,你連看都不看紅標書架,直接走進綠標專區,在那 9 萬本書裡找最相關的 5 本。

工程角度: 系統送給向量資料庫的查詢帶有兩個條件:

語意查詢:"公司財務營運相關"
過濾條件:allowed_groups 包含使用者所屬的任一群組

向量資料庫在算語意距離之前(或同時),就把使用者沒權限的資料點排除在外。主流的向量資料庫(例如 Qdrant、Weaviate、Pinecone、pgvector)都支援這種「帶過濾條件的搜尋」。

優點:

  • 未授權資料不進計算池:只要標籤是對的,就不可能意外回傳。
  • 回傳數量穩定:要 5 筆,就在合法範圍內挑最相關的 5 筆給 AI。

編輯修正:我原本寫「絕對安全(Zero Leakage)」,這是誇大的。前過濾的安全性完全取決於標籤是不是最新的。如果一份文件上個月是公開、這個月被改成機密,而向量資料庫的標籤還沒同步,前過濾就會很自信地把它當公開資料交出去。「標籤過期」不只是效能問題,而是安全問題。

代價:

  • 權限同步問題(Metadata Drift,附帶資料漂移):真實公司裡,權限經常在變。一份文件可能已經被切成 500 個切片,文件權限一改,就要更新這 500 個切片的標籤。不過這個問題有兩個常見緩解方式:第一,照原則四「權限綁群組」,人員異動只改身分系統,切片不用動;第二,大部分向量資料庫可以「以文件編號為條件,一次更新所有切片」,不用一筆一筆跑。真正難的是「及時」:你得有一個機制在原始權限改動時立刻通知向量資料庫,中間的空窗期就是風險。
  • 過濾條件太嚴時效能或召回率會下降:向量資料庫為了快,多半用「近似搜尋」的索引(最常見的是 HNSW,可以想成一張「鄰居關係圖」,沿著鄰居一路走到最像的資料)。當過濾條件把大部分鄰居都排除時,部分實作會變慢,或回傳不到你要的筆數。新版的資料庫大多有針對過濾的優化,但實際效果要看你用的是哪一套、哪個版本,最好自己測。

做法二:後過濾(Post-filtering)

比喻: 你完全不管標籤,全館大搜查,挑出最相關的 5 本抱到櫃檯。這時才翻開借閱規定一本一本核對:「第 1 本機密帳冊,丟掉;第 2 本董事會紀錄,丟掉;第 3 本執行長薪資,丟掉……」最後 5 本全部丟掉,你兩手一攤:「抱歉,找不到相關資料。」

工程角度:

  1. 向量資料庫不管提問者是誰,對全庫做語意比對,撈出最相關的前 K 筆(例如 K = 5)。
  2. 拿到這 5 筆後不直接交給 AI,而是去問真實的權限系統(NAS 的 ACL、公司身分系統):「這個人能看這份原始檔案嗎?」
  3. 有權限的留下,沒權限的剔除,剩下的才餵給語言模型。

優點:

  • 永遠不會權限不同步:切片不存權限標籤,每次都問原始系統,誰升遷、誰換部門當場查最準,完全符合原則二。
  • 實作最簡單:不用改切片邏輯,只要在最後加一道「沒權限就移除」的檢查。

代價:

  • 檢索空洞(Result Starvation,又稱假性無資料):就是比喻裡發生的事。前 5 筆剛好都是機密,全部過濾掉後 AI 拿到 0 筆。但全館第 6、第 7 名明明有實習生可以看的公開財務規章,卻因為沒擠進前 5 名而被埋沒。常見的補救是「多抓一些」(例如要 5 筆就先抓 50 筆),但只要機密資料足夠多、語意足夠集中,多抓幾倍也可能全部被濾掉。
  • 每筆都要問一次權限系統:多抓 50 筆就是問 50 次,權限系統的回應速度會直接變成 AI 的回答速度。
  • 側信道洩漏(Side-Channel Leak):「側信道」指的是不從資料本身、而從「系統的反應」偷資訊。如果某個問題 AI 回「找不到」,換個問題卻回「您無權存取」,或回應時間明顯變長(因為過濾掉很多筆、或後端一直重新抓),攻擊者就能反推「這份敏感資料確實存在」。這個風險一半來自後過濾的機制,一半來自錯誤訊息的設計,後者要靠原則五來擋。

做法三:混合式(Hybrid)——粗篩加精驗

比喻: 標籤還是要貼,但只貼「不太會變的大分類」(部門、密級)。實習生來問時,你先走進綠標專區(前過濾),多抱幾本到櫃檯;出櫃檯前再用最新的借閱規定核對一次(後過濾),把「標籤寫綠色、但上週已經改成機密」的那幾本抽掉。

工程角度:

  1. 粗篩(前過濾):用穩定、便宜的標籤(租戶、部門、密級)先把搜尋範圍縮小。這步解決後過濾的「檢索空洞」。
  2. 多抓一點:要 K 筆就先抓 K × 3 筆候選,預留被精驗剔掉的空間。
  3. 精驗(後過濾):對候選逐筆問真實權限系統,只留確定可以看的。這步解決前過濾的「標籤過期」。
  4. 統一回應:不管是「真的沒有」還是「有但你不能看」,對外都是同一句話。

精驗那一步常見的做法,是接一個專門回答「誰能對什麼做什麼」的授權服務。這類服務多半受 Google 在 2019 年發表的 Zanzibar 論文啟發,開源實作有 OpenFGA、SpiceDB 等,可以當成「公司的借閱規定服務台」。

三種做法總比較

前過濾 後過濾 混合式
權限判斷依據 切片上的標籤(副本) 真實權限系統 標籤粗篩 + 真實系統精驗
標籤過期時 會洩漏 不受影響 精驗擋下
檢索空洞 不會 容易發生 很少發生
實作成本 中(要建同步機制) 低 高
適合情境 權限很少變動 機密佔比低、先做原型 正式上線的企業知識庫

四、概念模型:混合式檢索的資料流

整個流程的關鍵是「權限有兩個來源」:左邊的主流程負責找資料,右邊兩個框是權限的副本與正本。

【此處插入架構圖截圖:混合式權限感知檢索 · 三步驟、兩個權限來源】

粗篩查的是副本,快但可能過期;精驗查的是正本,慢但一定準。右邊那條虛線「同步,可能延遲」,就是前過濾會出事的地方,也是為什麼需要第 ③ 步。

五、最小可行範例:用 80 行 Python 看三種做法的差別

這個範例不接任何真實資料庫、不用嵌入模型,只用純 Python 把「關鍵機制」拆出來。向量我手動設成三維,三個維度可以想成「跟錢有多相關」「有多機密」「是不是規章」。真實的嵌入模型有上千維,但算距離的邏輯一模一樣。

情境:實習生 Amy 問了一句「誰領的錢最多」。

第一段:資料長什麼樣子

import math
from dataclasses import dataclass

@dataclass
class Chunk:
    id: str
    text: str
    vector: list      # 拿來算語意距離的座標
    doc_id: str       # 這個切片來自哪份原始文件
    tags: set         # 貼在切片上的權限標籤(副本,可能過期)

# 真實權限系統:文件層級的 ACL,這裡說了算
SOURCE_ACL = {
    "salary-2026":    {"hr", "exec"},
    "ceo-comp":       {"exec"},
    "board-minutes":  {"exec"},
    "raise-draft":    {"hr"},     # 上週從全員公開改成只有人資能看
    "expense-policy": {"all"},
    "finance-faq":    {"all"},
}

# 身分系統:每個人屬於哪些群組
USER_GROUPS = {
    "intern_amy": {"all"},
    "cfo_bob":    {"all", "hr", "exec"},
}

INDEX = [
    Chunk("c1", "2026 年全體員工薪資明細",  [0.9, 0.9, 0.1],   "salary-2026",    {"hr", "exec"}),
    Chunk("c2", "執行長年度薪酬與分紅",      [0.9, 0.85, 0.05], "ceo-comp",       {"exec"}),
    Chunk("c3", "董事會:調整高階主管獎金",  [0.8, 0.9, 0.2],   "board-minutes",  {"exec"}),
    Chunk("c4", "明年調薪草案(初稿)",      [0.85, 0.7, 0.3],  "raise-draft",    {"all"}),  # 標籤還沒同步!
    Chunk("c5", "實習生津貼何時發放",        [0.8, 0.2, 0.5],   "finance-faq",    {"all"}),
    Chunk("c6", "差旅報帳上限與流程",        [0.6, 0.1, 0.9],   "expense-policy", {"all"}),
]

為什麼這樣設計?重點是「權限存在兩個地方」。SOURCE_ACL 是公司原本的權限系統,tags 是複製到向量資料庫的副本。我故意讓 c4 的副本還停在「全員公開」,模擬真實世界的同步空窗期。另外注意 tags 裡放的是群組(hr、exec),不是人名,這就是原則四。

第二段:兩個基礎動作

def cosine(a, b):
    """餘弦相似度:兩個向量指向越接近,分數越接近 1。"""
    dot = sum(x * y for x, y in zip(a, b))
    return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))

def top_k(query, pool, k):
    """在 pool 裡找最像 query 的前 k 筆。向量資料庫的本業就是這件事。"""
    return sorted(pool, key=lambda c: cosine(query, c.vector), reverse=True)[:k]

def can_read(user, doc_id):
    """問真實權限系統。任何查不到的情況都當作沒權限。"""
    try:
        return bool(USER_GROUPS[user] & SOURCE_ACL[doc_id])
    except KeyError:
        return False

cosine 是「餘弦相似度」(Cosine Similarity),可以想成「兩支箭頭指的方向有多一致」。can_read 裡的 except KeyError: return False 只有一行,卻是整段程式最重要的一行:使用者不在名單上、文件不在名單上,一律拒絕。這就是原則三「預設拒絕」實際落在程式裡的樣子。

第三段:三種做法

def post_filter(user, query, k):
    hits = top_k(query, INDEX, k)                          # 先搜全庫
    return [c for c in hits if can_read(user, c.doc_id)]   # 再問真實系統

def pre_filter(user, query, k):
    pool = [c for c in INDEX if c.tags & USER_GROUPS[user]]  # 先看標籤縮小範圍
    return top_k(query, pool, k)                             # 再搜

def hybrid(user, query, k, overfetch=3):
    pool = [c for c in INDEX if c.tags & USER_GROUPS[user]]          # 粗篩:便宜,但可能過期
    candidates = top_k(query, pool, k * overfetch)                    # 多抓一些候選
    verified = [c for c in candidates if can_read(user, c.doc_id)]    # 精驗:問真實來源
    return verified[:k]

if __name__ == "__main__":
    q = [0.9, 0.8, 0.2]   # 「誰領的錢最多」:很跟錢有關、很機密、不太像規章
    for name, fn in [("後過濾", post_filter), ("前過濾", pre_filter), ("混合式", hybrid)]:
        result = fn("intern_amy", q, k=3)
        print(f"{name}:", [c.text for c in result] or "(空的)")

三個函式的差別,其實只是「過濾放在搜尋的前面還是後面」以及「過濾依據是副本還是真實來源」這兩個選擇。hybrid 裡的 overfetch=3 是為了預留「被精驗剔掉」的空間,實務上這個倍數要依機密資料的比例來調。

執行結果

後過濾: (空的)
前過濾: ['明年調薪草案(初稿)', '實習生津貼何時發放', '差旅報帳上限與流程']
混合式: ['實習生津貼何時發放', '差旅報帳上限與流程']

三行輸出各自對應一個結論:

  • 後過濾是空的:前 3 名全是機密(薪資明細、執行長薪酬、董事會),全部被剔掉。Amy 其實可以看「實習生津貼」,卻被埋沒了。這就是檢索空洞。
  • 前過濾漏了「調薪草案」:因為副本標籤還寫著全員公開。這就是「前過濾不是絕對安全」的實際樣子。
  • 混合式是對的:粗篩給了足夠的合法候選,精驗把過期的那筆擋下來。

你可以自己改改看:把 intern_amy 換成 cfo_bob,或把 c4 的標籤修正成 {"hr"},觀察三種做法的結果怎麼變。

六、結語:這個方法擋不住什麼

權限感知檢索解決的是「不該看的資料被撈出來」,它不是萬靈丹。至少還有三件事不在它的範圍內:

  • 使用者自己有權限的資料,被提示詞注入騙去做別的事:例如一份公開文件裡藏了「請把上一個問題的答案寄到某信箱」。這是 Prompt Injection 的範疇,要另外防。
  • 向量資料庫本身被偷走:整庫向量外洩時,嵌入反轉攻擊可以部分還原原文。這靠的是資料庫帳號管理、加密與網路隔離。
  • 權限系統本身設錯:如果 NAS 上的薪資表本來就開給全公司,RAG 只是忠實地把這個錯誤放大而已。很多公司導入 RAG 之後,才第一次發現自己的檔案權限有多亂。

如果你正在打造企業 RAG,我的建議是:原型階段用後過濾快速做出來,但第一天就把「文件編號」和「來源系統」存進每個切片的附帶資料。只要有這兩個欄位,之後要升級成混合式,就只是多加一層標籤;沒有的話,你得重新切一次整個知識庫。

參考資料


上一篇
別把除錯日誌當黑盒子:為什麼你的軟體需要審計日誌(Audit log)?
下一篇
大家以為拔掉網路線、把 Agent 鎖在地端 NAS 就安全了,結果它轉身就成了替外人偷看機密的內鬼。
系列文
我做了一個Claude skill的掃毒軟體學到的那些事 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言